Skip to content

Fix codegen cycles and nullable-flag input parser (2.2.1)#390

Merged
jeremydmiller merged 2 commits into
mainfrom
fix/2.2.1-codegen-cycles-and-input-parser-nullable
May 29, 2026
Merged

Fix codegen cycles and nullable-flag input parser (2.2.1)#390
jeremydmiller merged 2 commits into
mainfrom
fix/2.2.1-codegen-cycles-and-input-parser-nullable

Conversation

@jeremydmiller
Copy link
Copy Markdown
Member

@jeremydmiller jeremydmiller commented May 28, 2026

Summary

Two regressions surfaced in 2.2.0 when downstream consumers
(Wolverine 6.2.0, Marten 9.3.0 + a code-generating container that
inlines singletons) exercise the inline IEnumerable<T> codegen path.

  • InputParserGenerator — the fallback converter expression emits
    FindConverter(typeof(T))! (Func<string, object?>) which can't bind
    to GeneratedFlag<T>'s Func<string, T> for nullable primitives like
    int? and DateTime?, producing CS1503 in the generated parser.
    Wrap with a cast (s => (T)…(s)), mirroring GetElementConverterExpression.

  • DependencyGatherer — co-recursive yield iterators with no cycle
    protection. Frame⇄Variable graphs in real handlers cycle (or just go
    deep enough — ~250+ levels — to blow the stack). Rewrite both
    walks as a single iterative BFS with HashSet-tracked visited nodes.
    Preserve the original walk's side effect of populating the Variables
    cache so MethodFrameArranger.findInjectedFields (which reads
    Variables.Keys()) still sees every injected field — otherwise the
    generated handler class loses its _field declarations and the
    method body references undeclared identifiers (CS0103).

Bumps JasperFxVersion 2.2.0 → 2.2.1 (Fix). JasperFx.RuntimeCompiler
is independently versioned and untouched.

Originally included, now reverted

An earlier draft of this PR also rebound EnumerableSingletons.KeyedMirror's
factory to the source ServiceDescriptor directly (instead of
sp.GetServices(elementType).ElementAt(ordinal)) to break a stack
overflow seen with AddJasperFxEnumerableSingletonSupport() against a
Lamar container. That change broke
inline_enumerable_with_mixed_lifetimes.{two_singletons_among_mixed_are_each_shared, singleton_element_is_the_shared_container_singleton} in CI: for sources
registered as AddSingleton<T, Impl>() / AddSingleton<T>(factory), the
rebound factory built a fresh instance instead of returning the cached
non-keyed singleton, defeating the whole "mirror" contract.

The cycle is rooted in Lamar's ServiceFamily combining keyed and
non-keyed instances into one family per service type, so when
KeyedMirror's factory calls sp.GetServices(T), Lamar's
ListInstance.Elements includes the keyed mirror itself and Lamar
re-inlines it via InjectedServiceField.ToVariableExpression's
QuickResolve. MS DI's GetServices excludes keyed registrations, so
this is Lamar-specific. Will follow up separately.

Test plan

  • CodegenTests.Services.inline_enumerable_with_mixed_lifetimes.* — 13/13 pass locally.
  • CodegenTests full suite — 397/397 pass locally (net9 + net10).
  • CommandLineTests full suite — 285/285 pass locally (net9 + net10).
  • CI green on the test-codegen job and the four shipping projects
    (JasperFx, JasperFx.SourceGenerator, JasperFx.Events,
    JasperFx.Events.SourceGenerator).
  • Manual: a NetCoreInput subclass with int? / DateTime? flag
    properties compiles via the input-parser generator.
  • Manual: a Wolverine/Marten host whose WolverineRuntime ctor
    previously stack-overflowed in DependencyGatherer.findDependencies
    starts cleanly and runs a handler.

🤖 Generated with Claude Code

jeremydmiller and others added 2 commits May 28, 2026 18:33
Three independent regressions surfaced when Wolverine 6.2.0 and Marten
9.3.0 (both depending on JasperFx 2.2.0) run against a host that
exercises the inline IEnumerable<T> codegen path. Together they prevent
host startup; each contributes one stack-overflow or compile failure.

1. InputParserGenerator emits a `Func<string, object?>` for any flag
   type that falls through the primitive switch — notably nullable
   primitives like `int?` and `DateTime?`. `GeneratedFlag<T>` takes
   `Func<string, T>`, so the generated parser fails to compile with
   CS1503. Mirror the cast wrapper that GetElementConverterExpression
   already uses: `s => ({T})...FindConverter(typeof({T}))!(s)`. Existing
   primitive fast paths are unchanged.

2. DependencyGatherer's two co-recursive yield iterators have no cycle
   protection — Frame.Uses → Variables[v] → Variable.Creator →
   Dependencies[creator] can loop, and even acyclic-but-deep graphs (the
   Wolverine/Marten WolverineRuntime constructor service graph) blow
   the stack at ~250 levels. Replace both iterators with a single
   iterative BFS that walks the (Frame, Variable) closure once with
   HashSet-tracked visited nodes. The original walk had a side effect
   of populating `Variables` cache keys that MethodFrameArranger.
   findInjectedFields reads via `Variables.Keys()`; preserve that by
   `Fill`-ing each visited Variable as the BFS sees it, otherwise
   generated handler classes lose their injected field declarations and
   their method bodies reference identifiers that aren't declared
   (CS0103).

3. EnumerableSingletons.KeyedMirror's factory was
   `(sp, _) => sp.GetServices(elementType).ElementAt(ordinal)`. When a
   container inlines the singleton element of a mixed-lifetime
   IEnumerable<T> via QuickResolve (Lamar does this for any
   InjectedServiceField targeting a Singleton), the factory re-enters
   the same IEnumerable<T> resolution while one of its elements is
   being built — infinite recursion that stack-overflows in ~750 nested
   ListAssignmentFrame.WriteExpressions / QuickResolve frames before any
   handler runs. Bind directly to the source ServiceDescriptor's
   ImplementationInstance / ImplementationFactory /
   ImplementationType+ActivatorUtilities so the lookup never round-trips
   through GetServices. Updated ArrayPlan and
   EnumerableSingletonRegistrationExtensions to pass the source
   descriptor through.

Bumps JasperFxVersion 2.2.0 → 2.2.1 (Fix). JasperFx.RuntimeCompiler is
independently versioned and unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The KeyedMirror change in 0e68505 made the keyed mirror's factory bind
directly to the source ServiceDescriptor's ImplementationInstance /
ImplementationFactory / ImplementationType. That broke
CodegenTests.Services.inline_enumerable_with_mixed_lifetimes
(`two_singletons_among_mixed_are_each_shared`,
`singleton_element_is_the_shared_container_singleton`): for sources
registered as `AddSingleton<T, Impl>()` or `AddSingleton<T>(factory)`,
the rebound factory built a fresh instance instead of returning the
container's cached non-keyed singleton, so the keyed mirror no longer
mirrored.

The original `sp.GetServices(elementType).ElementAt(ordinal)` factory
is necessary to preserve sharing — the container's cached singleton
only flows through GetServices.

Lamar's IEnumerable<T> codegen cycle (the original motivation for the
rebind) is rooted in Lamar's `ServiceFamily` building one family per
service type with keyed and non-keyed instances combined: when
KeyedMirror's factory calls `sp.GetServices(T)`, Lamar's
`ListInstance.Elements` includes the keyed mirror itself and Lamar
re-inlines it via `InjectedServiceField.ToVariableExpression`'s
QuickResolve. MS DI's `GetServices` excludes keyed registrations, so
the cycle is Lamar-specific. The right fix is in Lamar (or a
filter applied via JasperFx that Lamar consults), not in
KeyedMirror — defer to a follow-up.

This commit keeps the InputParserGenerator and DependencyGatherer
fixes from 0e68505 and the 2.2.0 → 2.2.1 version bump.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@jeremydmiller jeremydmiller merged commit 6347f91 into main May 29, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant